<?php

/*
Example usage:
$container = new WebClientCookieContainer();
$client = new WebClient();
$client->setCookieContainer($container);
$url = "http://myservice.com/sevice1.svc?user=x&password=y";
$response = $client->sendRequest($url, WebClient::HTTP_VERB_GET);
echo "<pre>\r\n\r\n";
var_dump($response);
$response = $client->sendRequest($url, WebClient::HTTP_VERB_GET);
var_dump($response);
echo "\r\n</pre>";
*/

require_once "webcore.php";
require_once "webcore.collections.php";
require_once "webcore.serialization.php";

/**
 * Represents an Http response generated by a WebClient
 * @package WebCore
 * @subpackage Web
 */
class WebClientResponse extends SerializableObjectBase
{
    /**
     * @var KeyedCollection
     */
    protected $headers;
    protected $httpVersion;
    protected $responseCode;
    protected $responseMessage;
    protected $body;
    
    const HTTP_VERSION_10 = "1.0";
    const HTTP_VERSION_11 = "1.1";
    
    /**
     * Creates a new instance of this class
     */
    public function __construct()
    {
        $this->responseCode = -1;
        $this->headers = new KeyedCollection();
        $this->body = "";
        $this->httpVersion = self::HTTP_VERSION_11;
    }
    
    /**
     * Creates a default instance of this class
     * @return WebClientResponse
     */
    public static function createInstance()
    {
        return new WebClientResponse();
    }
    
    /**
     * Gets a keyed collection of response headers
     * @return KeyedCollection
     */
    public function getHeaders()
    {
        return $this->headers;
    }
    
    /**
     * Gets a response header value given its name. If not found, returns null
     * @return mixed
     */
    public function getHeader($headerName)
    {
        foreach ($this->headers as $key => $value)
        {
            if (strtolower($headerName) === strtolower($key))
            {
                return $this->headers->getItem($key);
            }
        }
        
        return null;
    }

    /**
     * Sets a header by name and value
     * @param string $name
     * @param string $value
     */
    public function setHeader($name, $value)
    {
        if (is_int($name)) list($name, $value) = explode(":", $value, 1);
        $this->headers->setValue(ucwords(strtolower($name)), trim($value));
    }

    /**
     * Gets the HTTP reponse conde
     * @return float
     */
    public function getResponseCode()
    {
        return floatval($this->responseCode);
    }
    
    /**
     * Sets the HTTP response code
     * @param float $value
     */
    public function setResponseCode($value)
    {
        $this->responseCode = floatval($value);
    }
    
    /**
     * Gets the corresponding HTTP response message according to the code
     * @return string
     */
    public function getResponseMessage()
    {
        return $this->responseMessage;
    }
    
    /**
     * Sets the HTTP status message for this response
     * @param string $value
     */
    public function setResponseMessage($value)
    {
        $this->responseMessage = $value;
    }
    
    /**
     * Sets the response body of this response
     * @param string $value
     */
    public function setBody($value)
    {
        $this->body = $value;
    }
    
    /**
     * Gets the response body of this response
     * @return string
     */
    public function getBody()
    {
        return $this->body;
    }
    
    /**
     * Determines, based on the Response code property whether this response is an error response
     * @return bool
     */
    public function getIsError()
    {
        $code = floor($this->getResponseCode() / 100);
        return ($code == 4) || ($code == 5); 
    }
    
    /**
     * Determines, based on the Response code property whether this response is an successful response
     * @return bool
     */
    public function getIsSuccessful()
    {
        $code = floor($this->getResponseCode() / 100);
        return ($code == 1) || ($code == 2);  
    }
    
    /**
     * Determines, based on the Response code property whether this response is a redirection response
     * @return bool
     */
    public function getIsRedirect()
    {
        $code = floor($this->getResponseCode() / 100);
        return ($code == 3); 
    }
    
    /**
     * Sets the HTTP version of this response
     * @param string $value One of the HTTP_VERSION-prefixed values in this class
     */
    public function setHttpVersion($value)
    {
        if ($value !== self::HTTP_VERSION_10 && $value !== self::HTTP_VERSION_11)
            throw new SystemException(SystemException::EX_INVALIDPARAMETER, "Parameter value must be one of the HTTP_VERSION-prefixed constants defined");
        
        $this->httpVersion = $value;
    }
    
    /**
     * Gets the HTTP version of this response
     * @return string One of the HTTP_VERSION-prefixed values in this class
     */
    public function getHttpVersion()
    {
        return $this->httpVersion;
    }
    
    /**
     * (For internal use only)
     * @param string $responseString
     * @return float
     */
    private static function extractCode(&$responseString)
    {
        preg_match("|^HTTP/[\d\.x]+ (\d+)|", $responseString, $m);
        if (isset($m[1])) {
            return floatval($m[1]);
        } else {
            return -1;
        }
    }
    
    /**
     * (For internal use only)
     * @param string $responseString
     * @return string
     */
    private static function extractMessage(&$responseString)
    {
        preg_match("|^HTTP/[\d\.x]+ \d+ ([^\r\n]+)|", $responseString, $m);
        if (isset($m[1])) {
            return trim($m[1]);
        } else {
            return false;
        }
    }
    
    /**
     * (For internal use only)
     * @param string $responseString
     * @return string
     */
    public static function extractVersion(&$responseString)
    {
        preg_match("|^HTTP/([\d\.x]+) \d+|", $responseString, $m);
        if (isset($m[1])) {
            return trim($m[1]);
        } else {
            return false;
        }
    }
    
    /**
     * (For internal use only)
     * @param string $responseString
     * @return KeyedCollection
     */
    private static function extractHeaders(&$responseString)
    {
        $headers = array();
        
        // First, split body and headers
        $parts = preg_split('|(?:\r?\n){2}|m', $responseString, 2);
        if (! $parts[0]) return $headers;
        
        // Split headers part to lines
        $lines = explode("\n", $parts[0]);
        unset($parts);
        $last_header = null;

        foreach($lines as $line) {
            $line = trim($line, "\r\n");
            if ($line == "") break;

            if (preg_match("|^([\w-]+):\s+(.+)|", $line, $m)) {
                unset($last_header);
                $h_name = strtolower($m[1]);
                $h_value = $m[2];

                if (isset($headers[$h_name])) {
                    if (! is_array($headers[$h_name])) {
                        $headers[$h_name] = array($headers[$h_name]);
                    }

                    $headers[$h_name][] = $h_value;
                } else {
                    $headers[$h_name] = $h_value;
                }
                $last_header = $h_name;
            } elseif (preg_match("|^\s+(.+)$|", $line, $m) && $last_header !== null) {
                if (is_array($headers[$last_header])) {
                    end($headers[$last_header]);
                    $last_header_key = key($headers[$last_header]);
                    $headers[$last_header][$last_header_key] .= $m[1];
                } else {
                    $headers[$last_header] .= $m[1];
                }
            }
        }
        
        return $headers;
    }
    
    /**
     * (For internal use only)
     * @param string $response_str
     * @return string
     */
    private static function extractBody(&$responseString)
    {
        $parts = preg_split('|(?:\r?\n){2}|m', $responseString, 2);
        if (isset($parts[1]))
        { 
            return $parts[1];
        }
        
        return '';
    }
    
    /**
     * Creates a WebClientResponse object based on a raw HTTP response
     * @param string $response
     * @return WebClientResponse
     */
    public static function fromString(&$response)
    {
        $code    = self::extractCode($response);
        $headers = self::extractHeaders($response);
        $body    = self::extractBody($response);
        $version = self::extractVersion($response);
        $message = self::extractMessage($response);
        
        $WebClientResponse = new WebClientResponse();
        $WebClientResponse->setResponseCode($code);
        $WebClientResponse->setBody($body);
        $WebClientResponse->setHttpVersion($version);
        $WebClientResponse->setResponseMessage($message);
        
        foreach ($headers as $key => $value)
        {
            $WebClientResponse->setHeader($key, $value);
        }
        
        return $WebClientResponse;
    }
}


/**
 * Represents a cookie within a cookie container
 * @package WebCore
 * @subpackage Web
 */
class WebClientCookie extends SerializableObjectBase
{
    protected $values;
    protected $domain;
    protected $path;
    protected $expires;
    
    /**
     * Creates a new instance of this class
     * @param string $domain
     * @param string $path
     */
    public function __construct($domain = '', $path = '/')
    {
        $this->values = new KeyedCollection();
        $this->domain = $domain;
        $this->path = $path;
        $this->expires = '';
    }
    
    /**
     * Creates a default instance of this class
     * @retrun WebClientCookie
     */
    public static function createInstance()
    {
        return new WebClientCookie('','');
    }
    
    /**
     * Sets a value within the cookie
     * @param string $key
     * @param string $value
     */
    public function setValue($key, $value)
    {
        $this->values->setValue($key, $value);
    }
    
    /**
     * Gets a value within the cookie
     * @param string $key
     * @return string
     */
    public function getValue($key)
    {
        return $this->values->getValue($key);
    }
    
    /**
     * GEts the keyed coolection of internal cookie values
     * @return KeyedCollection
     */
    public function getValues()
    {
        return $this->values;
    }
    
    /**
     * Gets the cookie's domain
     * @return string
     */
    public function getDomain()
    {
        return $this->domain;
    }
    
    /**
     * Sets the cookie's domain
     * @param string $value
     */
    public function setDomain($value)
    {
        $this->domain = $value;
    }
    
    /**
     * Gets the cookie's path
     * @return string
     */
    public function getPath()
    {
        return $this->path;
    }
    
    /**
     * Sets the cookie's path
     * @param string $value
     */
    public function setPath($value)
    {
        $this->path = $value;
    }

    /**
     * Gets the cookie's expiration date
     * @return string
     */
    public function getExpires()
    {
        return $this->expires;
    }

    /**
     * Sets the cookie's expiration date
     * @param string $value
     */    
    public function setExpires($value)
    {
        $this->expires = $value;
    }

}

/**
 * Represents a WebClientCookie container. It's a cookie manager that is attachable to the WebClient object
 * @package WebCore
 * @subpackage Web
 */
class WebClientCookieContainer extends SerializableObjectBase
{
    protected $cookies;
    
    /**
     * Creates a new instance of this class
     */
    public function __construct()
    {
        $this->cookies = new IndexedCollection();
    }
    
    /**
     * Creates a default instance of this class
     * @return WebClientCookieContainer
     */
    public static function createInstance()
    {
        return new WebClientCookieContainer();
    }
    
    /**
     * Gets an indexed collection of cookies within this container
     * @return IndexedCollection
     */
    public function getCookies()
    {
        return $this->cookies;
    }
    
    /**
     * Helper method to set cookie from a set-cookie header
     * @param string $cookieHeader The value of the Set-Cookie header
     * @param string $url The url that is setting this cookie
     */
    public function setCookie($cookieHeader, $url)
    {
        $cookieParts = explode(';', $cookieHeader);
        $cookie = new WebClientCookie();
        $pathParts = parse_url($url);
        
        // default, in case not in cookie
        $cookie->setDomain($pathParts['host']);
        $cookie->setPath($pathParts['path']);
        
        foreach ($cookieParts as $cookiePart)
        {
            $nameValue  = explode('=', $cookiePart);
            $name = trim($nameValue[0]);
            $value = count($nameValue) > 1 ? trim($nameValue[1]) : '';
            
            if (strtolower($name) == 'domain')
            {
                $cookie->setDomain($value);
            }
            elseif (strtolower($name) == 'path')
            {
                $cookie->setPath($value);
            }
            elseif (strtolower($name) == 'expires')
            {
                $cookie->setExpires($value);
            }
            elseif (strtolower($name) == 'secure' || strtolower($name) == 'httponly')
            {
                // ignore
            }
            else
            {
                $cookie->setValue($name, $value);
            }
        }
        
        $replacedCookie = false;
        for ($i = 0; $i < $this->cookies->getCount(); $i++)
        {
            $currentCookie = $this->cookies->getItem($i);
            if ($currentCookie->getPath() === $cookie->getPath() && $currentCookie->getDomain() === $cookie->getDomain())
            {
                $this->cookies->setItem($i, $cookie);
                $replacedCookie = true;
                break;
            }
        }
        
        if (!$replacedCookie)
        {
            $this->cookies->addItem($cookie);
        }
    }
    
    
    /**
     * Gets a cookie given a domain and a path
     * @param string $domain
     * @param string $path
     * @return WebClientCookie Return null if the cookie is not found
     */
    public function getCookieByPath($domain, $path)
    {
        foreach($this->cookies as $cookie)
        {
            if (StringHelper::endsWith($domain, $cookie->getDomain()) &&
                StringHelper::beginsWith($path, $cookie->getPath()))
            {
                return $cookie;
            }
        }
        
        return null;
    }
}

/**
 * Represents an HTTP client to perform HTTP requests and obtain responses.
 *
 * @todo Add support to set HEADERS
 * @todo Include support to set CURLOPT_SSL_VERIFYPEER flag
 * 
 * @package WebCore
 * @subpackage Web
 */
class WebClient extends ObjectBase
{
    
    const HTTP_VERB_GET      = 'GET';
    const HTTP_VERB_PUT      = 'PUT';
    const HTTP_VERB_POST     = 'POST';
    const HTTP_VERB_DELETE   = 'DELETE';
    const HTTP_VERB_HEAD     = 'HEAD';
    const HTTP_VERB_MERGE    = 'MERGE';
    
    protected $userAgent;
    protected $requestHeaders;
    protected $requestVars;
    protected $proxyUrl;
    protected $proxyPort;
    protected $proxyUsername;
    protected $proxyPassword;
    
    protected $requestStartTime;
    protected $requestEndTime;
    protected $cookieContainer;

    protected $verifySslCertificate;
    
    protected $authUsername;
    protected $authPassword;
    protected $useAuthentication = false;
    
    /**
     * Creates a new instance of this class
     */
    public function __construct()
    {
        if (!extension_loaded('curl'))
        {
            throw new SystemException(SystemException::EX_CLASSNOTFOUND, 'cURL extension not loaded.');
        }
        
        $this->requestStartTime = 0.0;
        $this->requestEndTime = 0.0;
        
        $this->userAgent = "WebCore 3.0 Framework WebClient";
        
        $this->requestVars = new KeyedCollection();
        $this->proxyUrl = "";
        $this->proxyPort = 8080;
        $this->proxyUsername = "";
        $this->proxyPassword = "";
        
        $this->requestHeaders = new KeyedCollection();
        //$this->requestHeaders->setValue('Content-Type', '');
        //$this->requestHeaders->setValue('Expect', '');
        //$this->requestHeaders->setValue('Content-Length', '0');
        
        $this->verifySslCertificate = true;
        
        $this->cookieContainer = null;
    }
    
    /**
     * Gets the cookie container for this WebClient
     * @return WebClientCookieContainer
     */
    public function getCookieContainer()
    {
        return $this->cookieContainer;
    }
    
    /**
     * Sets the cookie container for this WebClient
     * @param WebClientCookieContainer $value
     */
    public function setCookieContainer(&$value)
    {
        if (!ObjectIntrospector::isA($value, 'WebClientCookieContainer'))
            throw new SystemException(SystemException::EX_INVALIDPARAMETER, 'Parameter value must be of type WebClientCookieContainer');
        $this->cookieContainer = $value;
    }
    
    /**
     * Gets the time at which the request bagan
     * @return float
     */
    public function getRequestStartTime() { return $this->requestStartTime; }
    /**
     * Gets the time at which the response was completely received
     * @return float
     */
    public function getRequestEndTime() { return $this->requestEndTime; }
    /**
     * Gets the time in seconds for how loong it took to make the request and receive the entire response
     * @return float
     */
    public function getRequestDuration() { return $this->requestEndTime - $this->requestStartTime; }
    
    /**
     * Provides direct access to the POST body variables to send
     * @return KeyedCollection
     */
    public function getRequestVars() { return $this->requestVars; }
    /**
     * Provides direct access to the request headers
     * @return KeyedCollection
     */
    public function getRequestHeaders() { return $this->requestHeaders; }
    
    /**
     * Gest the user agent to report to the server
     * @return string
     */
    public function getUserAgent() { return $this->userAgent; }
    /**
     * Sets the user agent to report to the server
     * @param string $value
     */
    public function setUserAgent($value) { $this->userAgent = $value; }
    /**
     * Gets the proxy url. If set to null, a proxy will not be used
     * @return string
     */
    public function getProxyUrl() { return $this->proxyUrl; }
    /**
     * Sets the proxy url. Set to null to avois using proxy
     * @param string $value
     */
    public function setProxyUrl($value) { $this->proxyUrl = $value; }
    /**
     * Gets the proxy port (8080 by default)
     * @return int
     */
    public function getProxyPort() { return intval($this->proxyPort); }
    /**
     * Sets the proxy port
     * @param int $value
     */
    public function setProxyPort($value) { $this->proxyPort = intval($value); }
    /**
     * Gets the username to use for the proxy
     * @return string
     */
    public function getProxyUsername() { return $this->proxyUsername; }
    /**
     * Sets the username to use with the proxy
     * @param string $value
     */
    public function setProxyUsername($value) { $this->proxyUsername = $value; }
    /**
     * Gest the password to use with the proxy
     * @return string
     */
    public function getProxyPassword() { return $this->proxyPassword; }
    /**
     * Sets the password to use with the proxy
     * @param string $value
     */
    public function setProxyPassword($value) { $this->proxyPassword = $value; }
    /**
     * Gets whether an invalid SSL certificate should be ignored
     * @param bool $value
     */
    public function getVerifySslCertificate() { return $this->verifySslCertificate; }
    /**
     * Sets whether an invalid SSL certificate should be ignored
     * @param bool $value
     */
    public function setVerifySslCertificate($value) { $this->verifySslCertificate = $value; }
    /**
     * Sets the username and password for basic authentication
     * @param string $username
     * @param string $password
     */
    public function setAuthentication($username, $password)
    {
        $this->useAuthentication = true;
        $this->authUsername = $username;
        $this->authPassword = $password;
    }
    /**
     * Removes the autentication parameters
     * @param string $username
     * @param string $password
     */
    public function unsetAuthentication()
    {
        $this->useAuthentication = false;
        $this->authUsername = null;
        $this->authPassword = null;
    }
    /**
     * Sends the request as currently configured by the properties of this object and returns a WebClientResponse
     * @return WebClientResponse
     */
    public function sendRequest($url, $httpVerb = WebClient::HTTP_VERB_GET)
    {
        
        // Set the cookie request header (if available)
        if (!is_null($this->cookieContainer))
        {
            $pathParts = parse_url($url);
            
            $cookie = $this->cookieContainer->getCookieByPath($pathParts['host'], $pathParts['path']);
            if (!is_null($cookie))
            {
                $this->requestHeaders->setValue('Cookie', $cookie->getValues()->implode('%s=%s', $separator = '; '));
            }
        }
        
        // Create a new cURL instance
        $curlHandle = curl_init();
        curl_setopt($curlHandle, CURLOPT_USERAGENT,       $this->userAgent);
        curl_setopt($curlHandle, CURLOPT_FOLLOWLOCATION,  true);
        curl_setopt($curlHandle, CURLOPT_TIMEOUT,         120);
        curl_setopt($curlHandle, CURLOPT_URL,             $url);
        curl_setopt($curlHandle, CURL_HTTP_VERSION_1_1,   true);
        curl_setopt($curlHandle, CURLOPT_HEADER,          true);
        curl_setopt($curlHandle, CURLOPT_RETURNTRANSFER,  true);
        // Ignore SSL 
        if ($this->verifySslCertificate === false)
        {
            curl_setopt($curlHandle, CURLOPT_SSL_VERIFYPEER, false);
            curl_setopt($curlHandle, CURLOPT_SSL_VERIFYHOST, false);
        }
        
        // Proxy setup (if required)
        if ($this->proxyUrl !== "")
        {
            curl_setopt($curlHandle, CURLOPT_PROXY,        $this->proxyUrl); 
            curl_setopt($curlHandle, CURLOPT_PROXYPORT,    $this->proxyPort); 
            curl_setopt($curlHandle, CURLOPT_PROXYUSERPWD,
                ($this->proxyUsername !== "" ? $this->proxyUsername : '') .
                ($this->proxyPassword !== "" ? ':' . $this->proxyPassword : '')); 
        }
        
        if ($this->useAuthentication)
        {
            curl_setopt($curlHandle, CURLOPT_USERPWD,
                ($this->authUsername !== "" ? $this->authUsername : '') .
                ($this->authPassword !== "" ? ':' . $this->authPassword : '')); 

        }
        switch ($httpVerb) {
            case self::HTTP_VERB_GET:
                curl_setopt($curlHandle, CURLOPT_HTTPGET, true);
                break;
            case self::HTTP_VERB_POST:
                curl_setopt($curlHandle, CURLOPT_POST,    true);
                break;
            case self::HTTP_VERB_HEAD:
                curl_setopt($curlHandle, CURLOPT_CUSTOMREQUEST,  $httpVerb);
                curl_setopt($curlHandle, CURLOPT_NOBODY, true);
                break;
            default:
                curl_setopt($curlHandle, CURLOPT_CUSTOMREQUEST,  $httpVerb);
                break;
        }
        
        if ($this->requestVars->getCount() > 0)
        {
            curl_setopt($curlHandle, CURLOPT_POSTFIELDS, $this->requestVars->getArrayReference());
        }
        
        // Add additional headers to cURL instance
        $curlHeaders = array();
        foreach ($this->requestHeaders as $key => $value)
        {
            $curlHeaders[] = $key.': '.$value;
        }
        
        curl_setopt($curlHandle, CURLOPT_HTTPHEADER,      $curlHeaders);
        
        // Execute request
        list($useg,$seg) = explode(' ',microtime());
        $this->requestStartTime = ((float)$useg + (float)$seg);
        $rawResponse = curl_exec($curlHandle);
        list($useg,$seg) = explode(' ',microtime());
        $this->requestEndTime = ((float)$useg + (float)$seg);
        
        $response    = null;
        if ($rawResponse)
        {
            $response = WebClientResponse::fromString($rawResponse);
        }
        else
        {
            throw new SystemException(SystemException::EX_INVALIDOPERATION,
                'cURL error occured during request for ' . $url . ': ' . curl_errno($curlHandle) . ' - ' . curl_error($curlHandle));
        }
        
        curl_close($curlHandle);
        
        if (!is_null($this->cookieContainer))
        {
            $cookieHeader = $response->getHeader('Set-cookie');
            if (!is_null($cookieHeader))
            {
                $this->cookieContainer->setCookie($cookieHeader, $url);
            }
        }
        
        return $response;
    }
}

?>